From 8e5d8fc92f9f9798f73ea1d4a06f9963a84c5034 Mon Sep 17 00:00:00 2001 From: Sicheng Dong Date: Wed, 14 Oct 2026 13:31:10 +0200 Subject: [PATCH 1/6] update timeout for llm from 600 to 1200 --- .../gen/gen_ai_hub/orchestration_v2/models/llm_model_details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..aca9fab2 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 @@ -28,7 +28,7 @@ class LLMModelDetails(BaseModel): 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) __all__ = ["LLMModelDetails"] From a08941390624216430afe3a66183d9fee071f579 Mon Sep 17 00:00:00 2001 From: Sicheng Dong Date: Wed, 14 Oct 2026 14:06:10 +0200 Subject: [PATCH 2/6] add test for timeout upperbound --- .../tests/orchestration_v2/test_llm_model_details_v2.py | 7 +++++++ 1 file changed, 7 insertions(+) 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..786edf35 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,13 @@ 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_model_details_with_unsupported_properties(self): with self.assertRaises(ValueError): LLMModelDetails(name="gpt-4o-mini", max_retries=10) From f555d1ed9ccd13353e6ace823aaf60822049bc7a Mon Sep 17 00:00:00 2001 From: Sicheng Dong Date: Wed, 14 Oct 2026 14:10:41 +0200 Subject: [PATCH 3/6] add code and tests for 'drop_unsupported_params' --- .../orchestration_v2/models/llm_model_details.py | 9 ++++++++- .../orchestration_v2/test_llm_model_details_v2.py | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) 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 aca9fab2..19954518 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,18 @@ 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 (default): 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. """ name: str version: Optional[str] = "latest" params: Optional[Dict] = None timeout: Optional[int] = Field(default=600, ge=1, le=1200) max_retries: Optional[int] = Field(default=2, ge=0, le=5) + drop_unsupported_params: Optional[StrictBool] = Field(default=True) __all__ = ["LLMModelDetails"] 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 786edf35..5388f79c 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 @@ -23,7 +23,8 @@ def test_llm_json_serialization(self): "version": "v1", "params": {"temperature": 0.7}, "max_retries": 2, - "timeout": 600 + "timeout": 600, + "drop_unsupported_params": True } json_data = llm.model_dump() self.assertEqual(json_data, expected_dict) @@ -35,6 +36,14 @@ def test_llm_timeout_upper_bound(self): with self.assertRaises(ValueError): LLMModelDetails(name="gpt-4o-mini", timeout=1201) + def test_llm_drop_unsupported_params(self): + llm = LLMModelDetails(name="gpt-4o-mini") + self.assertEqual(llm.drop_unsupported_params, True) + + 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) From 6ce0b76d87f5beed3bb1b31f36598c527ad777e6 Mon Sep 17 00:00:00 2001 From: Sicheng Dong Date: Wed, 14 Oct 2026 14:58:44 +0200 Subject: [PATCH 4/6] add the file content --- .../gen_ai_hub/orchestration_v2/__init__.py | 1 + .../orchestration_v2/models/__init__.py | 4 +- .../orchestration_v2/models/message.py | 12 +- .../models/multimodal_items.py | 114 +++++++++++++++++- .../orchestration_v2/test_flat_import.py | 1 + .../orchestration_v2/test_template_v2.py | 57 ++++++++- 6 files changed, 180 insertions(+), 9 deletions(-) diff --git a/packages/gen/gen_ai_hub/orchestration_v2/__init__.py b/packages/gen/gen_ai_hub/orchestration_v2/__init__.py index d378b8f7..000c9742 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/__init__.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/__init__.py @@ -47,6 +47,7 @@ # multimodal_items "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem", # response "CacheCreationTokenDetails", "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", 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..3e89729f 100644 --- a/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py +++ b/packages/gen/gen_ai_hub/orchestration_v2/models/__init__.py @@ -20,7 +20,8 @@ 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, @@ -81,6 +82,7 @@ # multimodal_items "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem", # response "CacheCreationTokenDetails", "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", 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/tests/orchestration_v2/test_flat_import.py b/packages/gen/tests/orchestration_v2/test_flat_import.py index ca877b41..7d046ee5 100644 --- a/packages/gen/tests/orchestration_v2/test_flat_import.py +++ b/packages/gen/tests/orchestration_v2/test_flat_import.py @@ -43,6 +43,7 @@ # multimodal_items "ImageDetailLevel", "TextPart", "ImageUrl", "ImagePart", "ContentPart", "ImageItem", + "FileContent", "FilePart", "FileItem", # response "PromptTokensDetails", "CompletionTokensDetails", "TokenUsage", "GenericModuleResult", "TopLogprob", 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) From 94397cc69b0c3068c31267077948c910c4316d86 Mon Sep 17 00:00:00 2001 From: Sicheng Dong Date: Wed, 14 Oct 2026 15:26:44 +0200 Subject: [PATCH 5/6] provider specific finish reason for LLMChoice and StreamLLMChoice --- .../orchestration_v2/models/response.py | 6 ++++ .../tests/orchestration_v2/test_message_v2.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) 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/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", From 5adcea314472017071b3d8bb79a997a29f0c11b9 Mon Sep 17 00:00:00 2001 From: Sicheng Dong Date: Thu, 15 Oct 2026 15:53:01 +0200 Subject: [PATCH 6/6] Set promp as optional in PromptTemplatingModuleConfig --- .../gen/gen_ai_hub/orchestration_v2/models/template.py | 2 +- packages/gen/tests/orchestration_v2/test_config_v2.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/tests/orchestration_v2/test_config_v2.py b/packages/gen/tests/orchestration_v2/test_config_v2.py index c9308399..a2cc3612 100644 --- a/packages/gen/tests/orchestration_v2/test_config_v2.py +++ b/packages/gen/tests/orchestration_v2/test_config_v2.py @@ -33,6 +33,14 @@ 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_input_filtering(self): input_filtering = InputFiltering(filters=[ AzureContentSafetyInputFilterConfig(config=AzureContentSafetyInput(hate=0))