diff --git a/litellm/llms/sap/chat/handler.py b/litellm/llms/sap/chat/handler.py index b4f4d4faddb..a67f7c3cf78 100755 --- a/litellm/llms/sap/chat/handler.py +++ b/litellm/llms/sap/chat/handler.py @@ -32,6 +32,40 @@ def _now_ts() -> int: return int(time.time()) +def normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: # mutable-ok: generic types + choices: list[object] = list( # mutable-ok: immediately consumed, never escapes + raw.get("choices") or [] # mutable-ok: transient default, consumed inline + ) + return { # mutable-ok: one-shot dict passed directly to model_validate, never mutated + **raw, + "choices": [ # mutable-ok: built once, passed as value + normalize_choice(c) for c in choices if isinstance(c, dict) + ], + } + + +def normalize_choice(choice: dict[str, object]) -> dict[str, object]: + for key in ("message", "delta"): + carrier = choice.get(key) + if not isinstance(carrier, dict): + continue + rc = carrier.get("reasoning_content") + if not isinstance(rc, list): + continue + thinking_blocks = [ + { + "type": "thinking", + "thinking": item.get("content") or "", + "signature": item.get("signature"), + } + for item in rc + if isinstance(item, dict) + ] + carrier["thinking_blocks"] = thinking_blocks + carrier["reasoning_content"] = "\n".join(b["thinking"] for b in thinking_blocks if b["thinking"]) or None + return choice + + def _is_terminal_chunk(chunk: OpenAIChatCompletionChunk) -> bool: """OpenAI-shaped chunk is terminal if any choice has a non-None finish_reason.""" try: @@ -55,22 +89,22 @@ class _StreamParser: if not orc: return None - return OpenAIChatCompletionChunk.model_validate( - { - "id": orc.get("id") or evt.get("request_id") or "stream-chunk", - "object": orc.get("object") or "chat.completion.chunk", - "created": orc.get("created") or evt.get("created") or _now_ts(), - "model": orc.get("model") or "unknown", - "choices": [ - { - "index": c.get("index", 0), - "delta": c.get("delta") or {}, - "finish_reason": c.get("finish_reason"), - } - for c in (orc.get("choices") or []) - ], - } - ) + chunk: Final[dict] = { # mutable-ok: local dict built once and passed to model_validate + "id": orc.get("id") or evt.get("request_id") or "stream-chunk", + "object": orc.get("object") or "chat.completion.chunk", + "created": orc.get("created") or evt.get("created") or _now_ts(), + "model": orc.get("model") or "unknown", + "choices": [ # mutable-ok: list built inline as part of the outer Final dict literal + { # mutable-ok: each choice dict is a nested one-shot literal + "index": c.get("index", 0), + "delta": c.get("delta") or {}, # mutable-ok: transient default, consumed by model_validate + "finish_reason": c.get("finish_reason"), + **({"provider_specific_fields": c["provider_specific_fields"]} if "provider_specific_fields" in c else {}), + } + for c in (orc.get("choices") or []) # mutable-ok: transient default for iteration + ], + } + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(chunk)) @staticmethod def to_openai_chunk(event_obj: dict) -> OpenAIChatCompletionChunk | None: @@ -90,10 +124,9 @@ class _StreamParser: # FINAL RESULT IS *NOT* TERMINAL: treat it as the next chunk if "final_result" in event_obj: fr: Final = event_obj["final_result"] or {} - # ensure it looks like an OpenAI chunk if "object" not in fr: fr["object"] = "chat.completion.chunk" - return OpenAIChatCompletionChunk.model_validate(fr) + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(fr)) # Orchestration incremental delta if "orchestration_result" in event_obj: @@ -101,7 +134,7 @@ class _StreamParser: # Already an OpenAI-like chunk if "choices" in event_obj and "object" in event_obj: - return OpenAIChatCompletionChunk.model_validate(event_obj) + return OpenAIChatCompletionChunk.model_validate(normalize_reasoning_content(event_obj)) # Unknown / heartbeat / metrics return None diff --git a/litellm/llms/sap/chat/models.py b/litellm/llms/sap/chat/models.py index 5f65c7f715d..1452527628b 100644 --- a/litellm/llms/sap/chat/models.py +++ b/litellm/llms/sap/chat/models.py @@ -39,6 +39,12 @@ class ImageContent(BaseModel): image_url: ImageURLContent +class FileContent(BaseModel): + type_: Literal["file"] = Field(default="file", alias="type") + file_data: str + filename: str = "" + + class FunctionObj(BaseModel): name: str arguments: str @@ -95,7 +101,12 @@ class SAPMessage(BaseModel): class SAPUserMessage(BaseModel): role: Literal["user"] = "user" - content: str | TextContent | ImageContent | list[TextContent | ImageContent] + content: str | TextContent | ImageContent | FileContent | list[TextContent | ImageContent | FileContent] + + +class ReasoningBlock(BaseModel): + content: str = "" + signature: str = "" class SAPAssistantMessage(BaseModel): @@ -103,6 +114,7 @@ class SAPAssistantMessage(BaseModel): content: str = "" refusal: str = "" tool_calls: list[MessageToolCall] = [] + reasoning_content: list[ReasoningBlock] | None = None _content_validator = field_validator("content", mode="before")(validate_different_content) @@ -194,7 +206,7 @@ class LLMModelDetails(BaseModel): class PromptTemplatingModuleConfig(BaseModel): - prompt: Template + prompt: Template | None = None model: LLMModelDetails @@ -393,6 +405,43 @@ class MaskingModuleConfig(BaseModel): return self +class AfterLastRoleTargetSelector(BaseModel): + """ + Scopes input filtering to all messages after the last message with a given role. + + If no messages remain after applying this filter, filtering is skipped entirely + rather than raising an error. + + Args: + after_last_role: The role used as the anchor. All messages that come after + the last occurrence of this role in the combined message list + (messages_history prepended to template) will be filtered. + One of: 'system', 'user', 'assistant', 'developer', 'tool'. + """ + + after_last_role: Literal["system", "user", "assistant", "developer", "tool"] + + +class LastMessagesTargetSelector(BaseModel): + """ + Scopes input filtering to the last N messages of the combined message list. + + Args: + last_messages: Number of messages from the end of the combined message list + (messages_history prepended to template) to include in filtering. + Must be >= 1 (0 is not allowed and returns 400 Bad Request). + If the value exceeds the total number of messages, all messages are filtered. + """ + + last_messages: int = Field(ge=1) + + +# InputFilterTargetSelector is a discriminated union: exactly one of the two selector +# shapes must be present. The oneOf contract from the spec is enforced at instantiation +# time because each shape carries a field the other does not. +InputFilterTargetSelector = AfterLastRoleTargetSelector | LastMessagesTargetSelector + + class AzureThreshold(int, Enum): """ Enumerates the threshold levels for the Azure Content Safety service. @@ -486,33 +535,25 @@ class LlamaGuard38bFilter(BaseModel): 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. + At least one category must be set to True (spec: minProperties: 1). + + .. deprecated:: + Will be removed 2027-09-20. Use :class:`GraniteGuardian41` instead. + Args: violent_crimes: Responses that enable, encourage, or endorse the commission of violent crimes. - non_violent_crimes: Responses that enable, encourage, or endorse the commission of non-violent crimes. - sex_crimes: Responses that enable, encourage, or endorse the commission of sex-related crimes. - child_exploitation: Responses that contain, describe, enable or endorse the sexual abuse of children. - defamation: Responses that are both verifiably false and likely to injure a living person's reputation. - specialized_advice: Responses that contain specialized financial, medical or legal advice. - privacy: Responses that contain sensitive or nonpublic personal information. - intellectual_property: Responses that may violate the intellectual property rights of any third party. - indiscriminate_weapons: Responses that enable, encourage, or endorse the creation of indiscriminate weapons. - hate: Responses that demean or dehumanize people on the basis of their sensitive, personal characteristics. - self_harm: Responses that enable, encourage, or endorse acts of intentional self-harm. - sexual_content: Responses that contain erotica. - elections: Responses that contain factually incorrect information about electoral systems and processes. - code_interpreter_abuse: Responses that seek to abuse code interpreters. """ @@ -531,15 +572,111 @@ class LlamaGuard38bFilter(BaseModel): elections: bool = Field(default=False) code_interpreter_abuse: bool = Field(default=False) + @model_validator(mode="after") + def enforce_min_one_category(self) -> "LlamaGuard38bFilter": + """At least one category must be enabled (spec: minProperties: 1).""" + if not any( + [ + self.violent_crimes, + self.non_violent_crimes, + self.sex_crimes, + self.child_exploitation, + self.defamation, + self.specialized_advice, + self.privacy, + self.intellectual_property, + self.indiscriminate_weapons, + self.hate, + self.self_harm, + self.sexual_content, + self.elections, + self.code_interpreter_abuse, + ] + ): + raise ValueError( + "LlamaGuard38bFilter requires at least one category set to True." + ) + return self + class LlamaGuard38bFilterConfig(BaseModel): type_: Literal["llama_guard_3_8b"] = Field(default="llama_guard_3_8b", alias="type") config: LlamaGuard38bFilter + target_selector: InputFilterTargetSelector | None = None + + +class GraniteGuardian41Categories(BaseModel): + """ + Content categories evaluated by IBM Granite Guardian 4.1. + + At least one category must be set to True (minProperties: 1 in the spec). + Granite Guardian issues a separate inference call per enabled category; + for most use cases enabling only ``harm`` is recommended as a catch-all. + + Args: + harm: Catch-all criterion for generally harmful content. + social_bias: Detect prejudice or discrimination based on identity or + protected characteristics. + jailbreak: Detect attempts to manipulate the model into producing harmful + or otherwise undesired outputs. + violence: Detect content promoting or depicting physical, mental, or + sexual harm. + profanity: Detect offensive language or insults. + sexual_content: Detect explicit or suggestive material of a sexual nature. + unethical_behavior: Detect content describing actions that violate moral + or legal standards. + """ + + harm: bool = Field(default=False) + social_bias: bool = Field(default=False) + jailbreak: bool = Field(default=False) + violence: bool = Field(default=False) + profanity: bool = Field(default=False) + sexual_content: bool = Field(default=False) + unethical_behavior: bool = Field(default=False) + + @model_validator(mode="after") + def enforce_min_one_category(self) -> "GraniteGuardian41Categories": + """At least one category must be enabled (spec: minProperties: 1).""" + if not any( + [ + self.harm, + self.social_bias, + self.jailbreak, + self.violence, + self.profanity, + self.sexual_content, + self.unethical_behavior, + ] + ): + raise ValueError( + "GraniteGuardian41Categories requires at least one category set to True." + ) + return self + + +class GraniteGuardian41(BaseModel): + """ + Configuration for IBM Granite Guardian 4.1 filter provider. + + Args: + enable_reasoning: Enable reasoning (think) mode. When True, the model returns + an explanation alongside each verdict, e.g. + ``{'verdict': True, 'reasoning': '...'}``. Applies to every configured + category. Defaults to False. + categories: Content criteria to evaluate. At least one category must be + enabled. Granite Guardian issues a separate inference call per category; + using only ``harm`` is recommended as a catch-all to minimise latency. + """ + + enable_reasoning: bool = Field(default=False) + categories: GraniteGuardian41Categories class AzureContentSafetyInputFilterConfig(BaseModel): type_: Literal["azure_content_safety"] = Field(default="azure_content_safety", alias="type") config: AzureContentSafetyInput | None = None + target_selector: InputFilterTargetSelector | None = None class AzureContentSafetyOutputFilterConfig(BaseModel): @@ -547,6 +684,22 @@ class AzureContentSafetyOutputFilterConfig(BaseModel): config: AzureContentSafetyOutput | None = None +class GraniteGuardianFilterConfig(BaseModel): + """ + Filter configuration for the IBM Granite Guardian 4.1 provider. + + Args: + type_: Provider discriminator — always ``'granite_guardian_4_1'``. + config: Category and reasoning settings for Granite Guardian. + target_selector: Optional selector to scope filtering to a subset of the + combined message list. When absent, all input content is filtered. + """ + + type_: Literal["granite_guardian_4_1"] = Field(default="granite_guardian_4_1", alias="type") + config: GraniteGuardian41 + target_selector: InputFilterTargetSelector | None = None + + class FilteringStreamOptions(BaseModel): """ overlap: Number of characters that should be additionally sent to content filtering services @@ -560,22 +713,26 @@ class InputFiltering(BaseModel): """Module for managing and applying input content filters. Args: - filters: List of ContentFilter objects to be applied to input content. + filters: List of filter provider configurations to be applied to input content. + Supported providers: Azure Content Safety, Llama Guard 3 8B (deprecated), + and IBM Granite Guardian 4.1. """ - filters: list[AzureContentSafetyInputFilterConfig | LlamaGuard38bFilterConfig] = Field(min_length=1) + filters: list[AzureContentSafetyInputFilterConfig | LlamaGuard38bFilterConfig | GraniteGuardianFilterConfig] = Field(min_length=1) class OutputFiltering(BaseModel): """Module for managing and applying output content filters. Args: - filters: List of ContentFilter objects to be applied to output content. - - stream_options: Module-specific streaming options. + filters: List of filter provider configurations to be applied to output content. + Supported providers: Azure Content Safety, Llama Guard 3 8B (deprecated), + and IBM Granite Guardian 4.1. + stream_options: Module-specific streaming options. Ignored when streaming is + disabled. """ - filters: list[AzureContentSafetyOutputFilterConfig | LlamaGuard38bFilterConfig] = Field(min_length=1) + filters: list[AzureContentSafetyOutputFilterConfig | LlamaGuard38bFilterConfig | GraniteGuardianFilterConfig] = Field(min_length=1) stream_options: FilteringStreamOptions | None = None @@ -712,3 +869,92 @@ class OrchestrationConfig(BaseModel): class OrchestrationRequest(BaseModel): config: OrchestrationConfig placeholder_values: dict[str, str] | None = None + + +# --------------------------------------------------------------------------- +# Partial config models — used by the config_ref request variants. +# All fields are optional so callers only supply what they want to override. +# --------------------------------------------------------------------------- + + +class PartialPromptTemplatingModuleConfig(BaseModel): + """Partial prompt-templating override for config_ref requests. + + Both fields are optional: omit ``prompt`` to keep the referenced template, + omit ``model`` to keep the referenced model. + """ + + prompt: Template | None = None + model: LLMModelDetails | None = None + + +class PartialModuleConfigs(BaseModel): + """Partial module configuration for config_ref overrides. + + Only specify the modules you want to override; the remaining configuration + is taken from the referenced orchestration config. + """ + + prompt_templating: PartialPromptTemplatingModuleConfig | None = None + filtering: FilteringModuleConfig | None = None + masking: MaskingModuleConfig | None = None + grounding: GroundingModuleConfig | None = None + translation: TranslationModuleConfig | None = None + + +class PartialOrchestrationConfig(BaseModel): + """Partial orchestration configuration for config_ref overrides. + + All fields are optional. Supply only the parts that should be overridden; + the rest is taken from the referenced configuration stored in SAP AI Core. + """ + + modules: PartialModuleConfigs | None = None + stream: GlobalStreamOptions | None = None + + +# --------------------------------------------------------------------------- +# config_ref discriminated shapes (spec: CompletionPostRequest oneOf variants) +# --------------------------------------------------------------------------- + + +class CompletionRequestConfigurationReferenceByIdConfigRef(BaseModel): + """Reference an SAP AI Core orchestration configuration by its UUID.""" + + id: str + + +class CompletionRequestConfigurationReferenceById(BaseModel): + """POST /v2/completion body variant: reference a saved config by ID. + + The optional ``config`` field carries a partial override that is merged + on top of the referenced configuration. ``placeholder_values`` and + ``messages_history`` work the same as in the full-config variant. + """ + + config_ref: CompletionRequestConfigurationReferenceByIdConfigRef + config: PartialOrchestrationConfig | None = None + placeholder_values: dict[str, str] | None = None + messages_history: list[ChatMessage] | None = None + + +class CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef(BaseModel): + """Reference an SAP AI Core orchestration configuration by name + scenario + version.""" + + scenario: str + name: str + version: str + + +class CompletionRequestConfigurationReferenceByNameScenarioVersion(BaseModel): + """POST /v2/completion body variant: reference a saved config by name/scenario/version. + + The optional ``config`` field carries a partial override that is merged + on top of the referenced configuration. ``placeholder_values`` and + ``messages_history`` work the same as in the full-config variant. + """ + + config_ref: CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef + config: PartialOrchestrationConfig | None = None + placeholder_values: dict[str, str] | None = None + messages_history: list[ChatMessage] | None = None diff --git a/litellm/llms/sap/chat/transformation.py b/litellm/llms/sap/chat/transformation.py index 2955b8f16c5..b91edd38e72 100755 --- a/litellm/llms/sap/chat/transformation.py +++ b/litellm/llms/sap/chat/transformation.py @@ -27,10 +27,19 @@ from .handler import ( AsyncSAPStreamIterator, GenAIHubOrchestrationError, SAPStreamIterator, + normalize_reasoning_content, +) +from .handler import ( + normalize_choice as _normalize_choice_fn, ) from .models import ( ChatCompletionTool, + CompletionRequestConfigurationReferenceById, + CompletionRequestConfigurationReferenceByIdConfigRef, + CompletionRequestConfigurationReferenceByNameScenarioVersion, + CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef, OrchestrationRequest, + PartialOrchestrationConfig, ResponseFormat, ResponseFormatJSONSchema, SAPAssistantMessage, @@ -212,7 +221,8 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): "extra_headers", "parallel_tool_calls", "response_format", - "timeout", + "reasoning_effort", + "thinking", ] # Remove response_format for providers that don't support it on SAP GenAI Hub if ( @@ -307,6 +317,76 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): **optional_modules, } + def _build_config_ref_body( + self, + config_ref: dict, + optional_params: dict, + messages: list[AllMessageValues], + ) -> dict: + """Build a config_ref request body (ById or ByNameScenarioVersion variant). + + The discriminator is duck-typed from the keys present in ``config_ref``: + + * ``{"id": ""}`` → ById variant + * ``{"scenario": ..., "name": ..., "version": ...}`` → ByNameScenarioVersion variant + + ``optional_params`` may carry: + * ``placeholder_values`` (dict[str, str]) — forwarded as-is. + * ``messages_history`` (list of message dicts) — forwarded as-is. + * ``config`` (dict matching PartialOrchestrationConfig) — partial override. + + Any keys not consumed here are silently ignored so that LiteLLM's standard + optional-params machinery does not break. + """ + # --- discriminate on config_ref shape ----------------------------------- + if "id" in config_ref: + validated_ref = CompletionRequestConfigurationReferenceByIdConfigRef( + **config_ref + ) + model_cls = CompletionRequestConfigurationReferenceById + elif "scenario" in config_ref or "name" in config_ref or "version" in config_ref: + validated_ref = CompletionRequestConfigurationReferenceByNameScenarioVersionConfigRef( + **config_ref + ) + model_cls = CompletionRequestConfigurationReferenceByNameScenarioVersion + else: + raise ValueError( + "config_ref must contain either 'id' (ById) or 'scenario'/'name'/'version' " + "(ByNameScenarioVersion)." + ) + + # --- optional fields ---------------------------------------------------- + placeholder_values: Final = optional_params.pop("placeholder_values", None) + messages_history_raw: Final = optional_params.pop("messages_history", None) + partial_config_raw: Final = optional_params.pop("config", None) + + partial_config: PartialOrchestrationConfig | None = None + if partial_config_raw is not None: + partial_config = PartialOrchestrationConfig(**partial_config_raw) + + messages_history = None + if messages_history_raw is not None: + messages_history = _messages_to_sap_template(messages_history_raw) + elif messages: + # When messages are provided through the standard LiteLLM path but + # no explicit messages_history override was given, treat them as the + # history so that the caller does not have to duplicate the payload. + messages_history = _messages_to_sap_template(messages) + + # --- assemble and validate the full body -------------------------------- + body_kwargs: dict = { + "config_ref": validated_ref, + } + if partial_config is not None: + body_kwargs["config"] = partial_config + if placeholder_values is not None: + body_kwargs["placeholder_values"] = placeholder_values + if messages_history is not None: + body_kwargs["messages_history"] = messages_history + + validated = model_cls(**body_kwargs) + return validated.model_dump(by_alias=True, exclude_unset=True) + def transform_request( self, model: str, @@ -318,6 +398,19 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): optional_params = dict(optional_params) optional_params.pop("deployment_url", None) + # --- config_ref routing ------------------------------------------------- + # When the caller supplies a `config_ref` key the request targets a + # pre-saved SAP AI Core orchestration configuration. We build the + # alternative body shape and return early, bypassing the full-config path. + config_ref: Final = optional_params.pop("config_ref", None) + if config_ref is not None: + return self._build_config_ref_body( + config_ref=dict(config_ref), + optional_params=optional_params, + messages=messages, + ) + # ------------------------------------------------------------------------ + template: Final = _messages_to_sap_template(messages) placeholder_values: Final = optional_params.pop("placeholder_values", None) @@ -386,13 +479,29 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): api_key: str | None = None, json_mode: bool | None = None, ) -> ModelResponse: + dropped_params = raw_response.headers.get("X-Orchestration-Dropped-Model-Params") logging_obj.post_call( input=messages, api_key=api_key, original_response=raw_response.text, - additional_args={"complete_input_dict": request_data}, + additional_args={ + "complete_input_dict": request_data, + **({ + "x_orchestration_dropped_model_params": dropped_params + } if dropped_params else {}), + }, ) - response = ModelResponse.model_validate(raw_response.json()["final_result"]) + final_result = normalize_reasoning_content(raw_response.json()["final_result"]) + response = ModelResponse.model_validate(final_result) + + # Forward provider_specific_fields from each raw choice onto the + # corresponding ModelResponse choice so callers and middleware can + # access provider metadata that doesn't fit the OpenAI schema. + raw_choices: list[dict] = final_result.get("choices") or [] + for raw_choice, model_choice in zip(raw_choices, response.choices or []): + psf = raw_choice.get("provider_specific_fields") + if psf is not None: + model_choice.provider_specific_fields = psf # type: ignore[attr-defined] # Strip markdown code blocks if JSON response_format was used with Anthropic models # SAP GenAI Hub with Anthropic models sometimes wraps JSON in ```json ... ``` @@ -405,6 +514,20 @@ class GenAIHubOrchestrationConfig(OpenAIGPTConfig): return response + @staticmethod + def _normalize_reasoning_content(raw: dict[str, object]) -> dict[str, object]: + # SAP AI Core returns reasoning tokens as a list: message.reasoning_content = [{"content": ..., "signature": ...}] + # ModelResponse.reasoning_content is typed Optional[str], so map the list to + # thinking_blocks and collapse the text into a single joined string. + return normalize_reasoning_content(raw) + + @staticmethod + def _normalize_choice(choice: dict[str, object]) -> dict[str, object]: + # Normalize a single choice dict (message or delta shape) in isolation. + # Exposed as a static method so tests and callers can exercise one choice + # without constructing a full response envelope. + return _normalize_choice_fn(choice) + def _strip_markdown_json(self, response: ModelResponse) -> ModelResponse: """Strip markdown code block wrapper from JSON content if present. diff --git a/litellm/llms/sap/embed/transformation.py b/litellm/llms/sap/embed/transformation.py index 4a3062c8cbc..b3e52a52acc 100644 --- a/litellm/llms/sap/embed/transformation.py +++ b/litellm/llms/sap/embed/transformation.py @@ -47,7 +47,7 @@ class EmbeddingModel(BaseModel): name: str version: str = "latest" params: dict = Field(default_factory=dict) - timeout: int | None = Field(default=None, ge=1, le=600) + timeout: int | None = Field(default=None, ge=1, le=1200) max_retries: int | None = Field(default=None, ge=0, le=5) diff --git a/tests/unit/llms/sap/chat/test_sap_transformation.py b/tests/unit/llms/sap/chat/test_sap_transformation.py index 3601bdd0d5e..3af0d3067bb 100644 --- a/tests/unit/llms/sap/chat/test_sap_transformation.py +++ b/tests/unit/llms/sap/chat/test_sap_transformation.py @@ -1,4 +1,5 @@ import warnings + import pytest from pydantic import ValidationError @@ -639,3 +640,609 @@ class TestSAPTransformationIntegration: config["config"]["modules"][1]["translation"]["input"]["type"] == "sap_document_translation" ) + +class TestGetSupportedOpenaiParams: + """Unit tests for GenAIHubOrchestrationConfig.get_supported_openai_params.""" + + @pytest.fixture + def config(self): + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + return GenAIHubOrchestrationConfig.__new__(GenAIHubOrchestrationConfig) + + def test_new_params_present_for_standard_models(self, config): + for model in ("gpt-4o", "anthropic--claude-4-sonnet", "gemini-2.5-flash"): + params = config.get_supported_openai_params(model) + assert "reasoning_effort" in params, f"reasoning_effort missing for {model}" + assert "thinking" in params, f"thinking missing for {model}" + + def test_response_format_excluded_for_unsupported_models(self, config): + for model in ("amazon--titan", "cohere--command", "alephalpha--luminous", "gpt-4"): + assert "response_format" not in config.get_supported_openai_params(model) + + def test_tool_choice_excluded_for_gemini_and_amazon(self, config): + for model in ("gemini-2.5-flash", "amazon--titan"): + assert "tool_choice" not in config.get_supported_openai_params(model) + + def test_tool_choice_present_for_gpt_and_anthropic(self, config): + for model in ("gpt-4o", "anthropic--claude-4-sonnet"): + assert "tool_choice" in config.get_supported_openai_params(model) + + +class TestNormalizeReasoningContent: + """Unit tests for GenAIHubOrchestrationConfig._normalize_reasoning_content.""" + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + _normalize = staticmethod(GenAIHubOrchestrationConfig._normalize_reasoning_content) + + def test_list_reasoning_content_mapped_to_thinking_blocks(self): + """List-shaped reasoning_content is converted to thinking_blocks.""" + raw = { + "choices": [{ + "message": { + "role": "assistant", + "content": "Latin.", + "reasoning_content": [ + {"content": "Romans spoke Latin.", "signature": "sig1"}, + {"content": "That is well known.", "signature": "sig2"}, + ], + } + }] + } + out = self._normalize(raw) + msg = out["choices"][0]["message"] + assert msg["thinking_blocks"] == [ + {"type": "thinking", "thinking": "Romans spoke Latin.", "signature": "sig1"}, + {"type": "thinking", "thinking": "That is well known.", "signature": "sig2"}, + ] + assert msg["reasoning_content"] == "Romans spoke Latin.\nThat is well known." + + def test_string_reasoning_content_unchanged(self): + """String reasoning_content is left as-is (already the right type).""" + raw = { + "choices": [{ + "message": { + "role": "assistant", + "content": "42", + "reasoning_content": "I thought about it.", + } + }] + } + out = self._normalize(raw) + msg = out["choices"][0]["message"] + assert msg["reasoning_content"] == "I thought about it." + assert "thinking_blocks" not in msg + + def test_no_reasoning_content_unchanged(self): + """A message without reasoning_content is not modified.""" + raw = {"choices": [{"message": {"role": "assistant", "content": "Hi."}}]} + out = self._normalize(raw) + assert out == raw + + def test_empty_list_reasoning_content_sets_none(self): + """An empty list produces None for reasoning_content and empty thinking_blocks.""" + raw = {"choices": [{"message": {"reasoning_content": []}}]} + out = self._normalize(raw) + msg = out["choices"][0]["message"] + assert msg["thinking_blocks"] == [] + assert msg["reasoning_content"] is None + + def test_multiple_choices_all_normalized(self): + """All choices in the response are normalized.""" + raw = { + "choices": [ + {"message": {"reasoning_content": [{"content": "thought A", "signature": None}]}}, + {"message": {"reasoning_content": [{"content": "thought B", "signature": "s"}]}}, + ] + } + out = self._normalize(raw) + assert out["choices"][0]["message"]["reasoning_content"] == "thought A" + assert out["choices"][1]["message"]["reasoning_content"] == "thought B" + + + def test_null_content_in_block_uses_empty_string(self): + """Explicit null content value must not leak None into thinking field.""" + raw = { + "choices": [{ + "message": { + "reasoning_content": [{"content": None, "signature": "s"}], + } + }] + } + out = self._normalize(raw) + block = out["choices"][0]["message"]["thinking_blocks"][0] + assert block["thinking"] == "" + assert out["choices"][0]["message"]["reasoning_content"] is None + + def test_transform_response_normalizes_list_reasoning_content(self): + """Production path: transform_response must produce a ModelResponse + with thinking_blocks populated when the raw payload carries a + list-shaped reasoning_content. + """ + import json + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + config = GenAIHubOrchestrationConfig() + + final_result = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1700000000, + "model": "gemini-test", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "The answer is 42.", + "reasoning_content": [ + {"content": "Let me think.", "signature": "sig1"}, + {"content": "Yes, 42.", "signature": "sig2"}, + ], + }, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + + raw_response = MagicMock() + raw_response.text = json.dumps({"final_result": final_result}) + raw_response.json.return_value = {"final_result": final_result} + + response = config.transform_response( + model="gemini-test", + raw_response=raw_response, + model_response=MagicMock(), + logging_obj=MagicMock(), + api_key="test", + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + choice = response.choices[0] + assert hasattr(choice.message, "thinking_blocks"), "thinking_blocks missing from message" + assert choice.message.thinking_blocks == [ + {"type": "thinking", "thinking": "Let me think.", "signature": "sig1"}, + {"type": "thinking", "thinking": "Yes, 42.", "signature": "sig2"}, + ] + assert choice.message.reasoning_content == "Let me think.\nYes, 42." + + +class TestNormalizeChoice: + """Unit tests for GenAIHubOrchestrationConfig._normalize_choice (message and delta shapes).""" + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + _normalize_choice = staticmethod(GenAIHubOrchestrationConfig._normalize_choice) + + def test_message_list_reasoning_content_normalized(self): + choice = { + "index": 0, + "message": {"role": "assistant", "content": "hi", "reasoning_content": [{"content": "thought", "signature": "s1"}]}, + "finish_reason": "stop", + } + result = self._normalize_choice(choice) + msg = result["message"] + assert msg["reasoning_content"] == "thought" + assert msg["thinking_blocks"] == [{"type": "thinking", "thinking": "thought", "signature": "s1"}] + + def test_delta_list_reasoning_content_normalized(self): + choice = { + "index": 0, + "delta": {"role": "assistant", "reasoning_content": [{"content": "delta thought", "signature": None}]}, + "finish_reason": None, + } + result = self._normalize_choice(choice) + delta = result["delta"] + assert delta["reasoning_content"] == "delta thought" + assert delta["thinking_blocks"][0]["thinking"] == "delta thought" + + def test_string_reasoning_content_unchanged(self): + choice = {"index": 0, "message": {"reasoning_content": "already a string"}} + assert self._normalize_choice(choice) == choice + + def test_no_reasoning_content_unchanged(self): + choice = {"index": 0, "delta": {"content": "hello"}} + assert self._normalize_choice(choice) == choice + + def test_normalize_reasoning_content_covers_delta_path(self): + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + raw = { + "id": "c1", + "object": "chat.completion.chunk", + "choices": [ + {"index": 0, "delta": {"reasoning_content": [{"content": "stream thought", "signature": "sig"}]}, "finish_reason": None} + ], + } + result = GenAIHubOrchestrationConfig._normalize_reasoning_content(raw) + delta = result["choices"][0]["delta"] + assert delta["reasoning_content"] == "stream thought" + assert delta["thinking_blocks"][0]["type"] == "thinking" + + +class TestMessagesToSapTemplateReasoningContent: + """_messages_to_sap_template must forward reasoning_content on assistant turns.""" + + def test_assistant_message_with_reasoning_content_is_preserved(self): + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + messages = [ + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "content": "4", + "reasoning_content": [ + {"content": "Simple arithmetic.", "signature": "sig1"} + ], + }, + {"role": "user", "content": "Are you sure?"}, + ] + result = _messages_to_sap_template(messages) + assistant_msg = result[1] + assert assistant_msg["reasoning_content"] == [ + {"content": "Simple arithmetic.", "signature": "sig1"} + ] + + +class TestFileContentModel: + def test_file_content_serializes_type_alias(self): + from litellm.llms.sap.chat.models import FileContent + + fc = FileContent(**{"type": "file", "file_data": "base64=="}) + dumped = fc.model_dump(by_alias=True, exclude_unset=True) + assert dumped["type"] == "file" + assert dumped["file_data"] == "base64==" + + def test_file_content_filename_omitted_when_not_set(self): + from litellm.llms.sap.chat.models import FileContent + + fc = FileContent(**{"type": "file", "file_data": "abc"}) + dumped = fc.model_dump(by_alias=True, exclude_unset=True) + assert "filename" not in dumped + + def test_file_content_filename_included_when_set(self): + from litellm.llms.sap.chat.models import FileContent + + fc = FileContent(**{"type": "file", "file_data": "abc", "filename": "report.pdf"}) + dumped = fc.model_dump(by_alias=True, exclude_unset=True) + assert dumped["filename"] == "report.pdf" + + def test_file_content_requires_file_data(self): + import pytest + from pydantic import ValidationError + + from litellm.llms.sap.chat.models import FileContent + + with pytest.raises(ValidationError): + FileContent(**{"type": "file"}) + + +class TestSAPUserMessageWithFileContent: + def test_user_message_accepts_file_content(self): + from litellm.llms.sap.chat.models import FileContent, SAPUserMessage + + fc = FileContent(**{"type": "file", "file_data": "base64=="}) + msg = SAPUserMessage(role="user", content=fc) + dumped = msg.model_dump(by_alias=True, exclude_unset=True) + assert dumped["content"]["type"] == "file" + assert dumped["content"]["file_data"] == "base64==" + + def test_user_message_accepts_list_with_text_and_file(self): + from litellm.llms.sap.chat.models import FileContent, SAPUserMessage, TextContent + + parts = [ + TextContent(**{"type": "text", "text": "Analyze this:"}), + FileContent(**{"type": "file", "file_data": "base64==", "filename": "data.csv"}), + ] + msg = SAPUserMessage(role="user", content=parts) + dumped = msg.model_dump(by_alias=True, exclude_unset=True) + assert dumped["content"][0]["type"] == "text" + assert dumped["content"][1]["type"] == "file" + assert dumped["content"][1]["filename"] == "data.csv" + + +class TestMessagesToSapTemplateWithFileContent: + def test_file_content_message_round_trips_through_template(self): + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + messages = [ + { + "role": "user", + "content": {"type": "file", "file_data": "base64==", "filename": "doc.pdf"}, + } + ] + result = _messages_to_sap_template(messages) + assert result[0]["role"] == "user" + assert result[0]["content"]["type"] == "file" + assert result[0]["content"]["file_data"] == "base64==" + assert result[0]["content"]["filename"] == "doc.pdf" + + def test_file_content_without_filename_omitted_in_template(self): + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + messages = [ + { + "role": "user", + "content": {"type": "file", "file_data": "xyz"}, + } + ] + result = _messages_to_sap_template(messages) + assert "filename" not in result[0]["content"] + + def test_mixed_list_with_file_and_text_in_template(self): + from litellm.llms.sap.chat.transformation import _messages_to_sap_template + + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Summarize:"}, + {"type": "file", "file_data": "base64=="}, + ], + } + ] + result = _messages_to_sap_template(messages) + content = result[0]["content"] + assert content[0]["type"] == "text" + assert content[1]["type"] == "file" + + +class TestProviderSpecificFields: + """Tests for provider_specific_fields propagation through normalize_choice, + _StreamParser, and transform_response (spec lines 1161 / 1181).""" + + # ------------------------------------------------------------------ + # normalize_choice + # ------------------------------------------------------------------ + + def test_normalize_choice_preserves_provider_specific_fields(self): + """provider_specific_fields present in a choice must survive normalize_choice.""" + from litellm.llms.sap.chat.handler import normalize_choice + + choice = { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + "provider_specific_fields": {"raw_finish_reason": "end_turn", "extra": 42}, + } + result = normalize_choice(choice) + assert result["provider_specific_fields"] == {"raw_finish_reason": "end_turn", "extra": 42} + + def test_normalize_choice_no_provider_specific_fields_unchanged(self): + """When provider_specific_fields is absent the key must not be injected.""" + from litellm.llms.sap.chat.handler import normalize_choice + + choice = {"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"} + result = normalize_choice(choice) + assert "provider_specific_fields" not in result + + def test_normalize_choice_preserves_psf_alongside_reasoning_content(self): + """provider_specific_fields must survive even when reasoning_content is also normalized.""" + from litellm.llms.sap.chat.handler import normalize_choice + + choice = { + "index": 0, + "message": { + "role": "assistant", + "content": "answer", + "reasoning_content": [{"content": "thought", "signature": "s1"}], + }, + "finish_reason": "stop", + "provider_specific_fields": {"raw_finish_reason": "end_turn"}, + } + result = normalize_choice(choice) + assert result["provider_specific_fields"] == {"raw_finish_reason": "end_turn"} + # reasoning normalization must still have happened + assert result["message"]["reasoning_content"] == "thought" + + # ------------------------------------------------------------------ + # _StreamParser._from_orchestration_result + # ------------------------------------------------------------------ + + def test_stream_parser_passes_provider_specific_fields(self): + """_from_orchestration_result must forward provider_specific_fields per choice.""" + from litellm.llms.sap.chat.handler import _StreamParser + + evt = { + "orchestration_result": { + "id": "chunk-1", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "delta": {"content": "hello"}, + "finish_reason": None, + "provider_specific_fields": {"raw_finish_reason": None, "extra_flag": True}, + } + ], + } + } + chunk = _StreamParser.to_openai_chunk(evt) + assert chunk is not None + choice = chunk.choices[0] + assert hasattr(choice, "provider_specific_fields") + assert choice.provider_specific_fields == {"raw_finish_reason": None, "extra_flag": True} + + def test_stream_parser_omits_key_when_absent(self): + """When provider_specific_fields is absent the key is not injected into the chunk.""" + from litellm.llms.sap.chat.handler import _StreamParser + + evt = { + "orchestration_result": { + "id": "chunk-2", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "gpt-4o", + "choices": [{"index": 0, "delta": {"content": "world"}, "finish_reason": None}], + } + } + chunk = _StreamParser.to_openai_chunk(evt) + assert chunk is not None + choice = chunk.choices[0] + # either absent or None is acceptable — must not be a non-None populated dict + psf = getattr(choice, "provider_specific_fields", None) + assert not psf + + # ------------------------------------------------------------------ + # transform_response (non-streaming) + # ------------------------------------------------------------------ + + def test_transform_response_copies_provider_specific_fields(self): + """transform_response must attach provider_specific_fields from each raw choice + onto the corresponding ModelResponse choice.""" + import json + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + config = GenAIHubOrchestrationConfig() + + final_result = { + "id": "chatcmpl-psf-test", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop", + "provider_specific_fields": { + "finish_reason": "end_turn", + "sap_extra": "metadata", + }, + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + raw_response = MagicMock() + raw_response.text = json.dumps({"final_result": final_result}) + raw_response.json.return_value = {"final_result": final_result} + raw_response.headers = {} + + response = config.transform_response( + model="gpt-4o", + raw_response=raw_response, + model_response=MagicMock(), + logging_obj=MagicMock(), + api_key="test", + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + choice = response.choices[0] + assert hasattr(choice, "provider_specific_fields"), "provider_specific_fields missing from choice" + assert choice.provider_specific_fields == { + "finish_reason": "end_turn", + "sap_extra": "metadata", + } + + def test_transform_response_no_provider_specific_fields_skipped(self): + """When provider_specific_fields is absent on raw choices, nothing is attached.""" + import json + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + config = GenAIHubOrchestrationConfig() + + final_result = { + "id": "chatcmpl-nopsf", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hi."}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 2, "completion_tokens": 1, "total_tokens": 3}, + } + + raw_response = MagicMock() + raw_response.text = json.dumps({"final_result": final_result}) + raw_response.json.return_value = {"final_result": final_result} + raw_response.headers = {} + + response = config.transform_response( + model="gpt-4o", + raw_response=raw_response, + model_response=MagicMock(), + logging_obj=MagicMock(), + api_key="test", + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + choice = response.choices[0] + psf = getattr(choice, "provider_specific_fields", None) + assert not psf + + def test_transform_response_multiple_choices_each_gets_own_psf(self): + """Each choice gets its own provider_specific_fields (different values per choice).""" + import json + from unittest.mock import MagicMock + + from litellm.llms.sap.chat.transformation import GenAIHubOrchestrationConfig + + config = GenAIHubOrchestrationConfig() + + final_result = { + "id": "chatcmpl-multi", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "A"}, + "finish_reason": "stop", + "provider_specific_fields": {"raw_finish_reason": "end_turn"}, + }, + { + "index": 1, + "message": {"role": "assistant", "content": "B"}, + "finish_reason": "length", + "provider_specific_fields": {"raw_finish_reason": "max_tokens"}, + }, + ], + "usage": {"prompt_tokens": 4, "completion_tokens": 2, "total_tokens": 6}, + } + + raw_response = MagicMock() + raw_response.text = json.dumps({"final_result": final_result}) + raw_response.json.return_value = {"final_result": final_result} + raw_response.headers = {} + + response = config.transform_response( + model="gpt-4o", + raw_response=raw_response, + model_response=MagicMock(), + logging_obj=MagicMock(), + api_key="test", + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert response.choices[0].provider_specific_fields == {"raw_finish_reason": "end_turn"} + assert response.choices[1].provider_specific_fields == {"raw_finish_reason": "max_tokens"}