From 6b591c34f141d4977078da2e7d67ad320d898f13 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Mon, 27 Apr 2026 17:15:11 +0530 Subject: [PATCH 1/6] fix(ovhcloud): migrate reasoning_content->reasoning and duration->seconds fields OVHCloud is deprecating two response fields on 2026-05-11: - reasoning_content replaced by reasoning (LLM reasoning models) - duration replaced by seconds (Speech-to-Text models) Adds backward-compatible support for both field names during the transition window, preferring the new field when present and falling back to the legacy field. Fixes #26586 --- .../audio_transcription/transformation.py | 8 ++ litellm/llms/ovhcloud/chat/transformation.py | 14 +++- ...loud_audio_transcription_transformation.py | 43 +++++++++++ .../test_ovhcloud_chat_transformation.py | 73 +++++++++++++++++++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/litellm/llms/ovhcloud/audio_transcription/transformation.py b/litellm/llms/ovhcloud/audio_transcription/transformation.py index 7ff6dc986be..e3c8308d507 100644 --- a/litellm/llms/ovhcloud/audio_transcription/transformation.py +++ b/litellm/llms/ovhcloud/audio_transcription/transformation.py @@ -156,5 +156,13 @@ class OVHCloudAudioTranscriptionConfig(BaseAudioTranscriptionConfig): text = response_json.get("text") or response_json.get("transcript") or "" response = TranscriptionResponse(text=text) + # OVHCloud field migration (deadline: 2026-05-11): + # `duration` is replaced by `seconds` in STT responses. + # Prefer `seconds`, fall back to `duration`, normalize to `duration` + # so downstream consumers see a consistent key. + duration = response_json.get("seconds") or response_json.get("duration") + if duration is not None: + response_json["duration"] = duration + response._hidden_params = response_json return response diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index ae9271ddb16..4100c548f2c 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -98,10 +98,16 @@ class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): new_choices = [] for choice in chunk["choices"]: - if "delta" in choice and "reasoning" in choice["delta"]: - choice["delta"]["reasoning_content"] = choice["delta"].get( - "reasoning" - ) + if "delta" in choice: + delta = choice["delta"] + # OVHCloud field migration (deadline: 2026-05-11): + # `reasoning_content` is replaced by `reasoning`. + # Normalise to `reasoning_content` so downstream consumers + # see a consistent key during the transition window. + reasoning_new = delta.get("reasoning") + reasoning_legacy = delta.get("reasoning_content") + if reasoning_new is not None and reasoning_legacy is None: + delta["reasoning_content"] = reasoning_new new_choices.append(choice) return ModelResponseStream( diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py index 8cc46dc98d0..e9abf50ba75 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py @@ -54,3 +54,46 @@ def test_ovhcloud_audio_transcription_config_installed(): assert config is not None assert isinstance(config, BaseAudioTranscriptionConfig) + + + +class TestOVHCloudDurationFieldMigration: + """Tests for OVHCloud duration -> seconds field migration.""" + + def test_seconds_field_mapped_to_duration(self): + """New `seconds` field should be normalized to `duration`.""" + from litellm.llms.ovhcloud.audio_transcription.transformation import ( + OVHCloudAudioTranscriptionConfig, + ) + from unittest.mock import MagicMock + + config = OVHCloudAudioTranscriptionConfig() + mock_response = MagicMock() + mock_response.json.return_value = { + "text": "Hello world", + "seconds": 3.14, + } + + result = config.transform_audio_transcription_response(mock_response) + + assert result.text == "Hello world" + assert result._hidden_params["duration"] == 3.14 + + def test_legacy_duration_field_still_works(self): + """Legacy `duration` field should still be accepted.""" + from litellm.llms.ovhcloud.audio_transcription.transformation import ( + OVHCloudAudioTranscriptionConfig, + ) + from unittest.mock import MagicMock + + config = OVHCloudAudioTranscriptionConfig() + mock_response = MagicMock() + mock_response.json.return_value = { + "text": "Hello world", + "duration": 2.71, + } + + result = config.transform_audio_transcription_response(mock_response) + + assert result.text == "Hello world" + assert result._hidden_params["duration"] == 2.71 \ No newline at end of file diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index a1b3b31f786..88ce3b4c296 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -292,3 +292,76 @@ def test_ovhcloud_with_custom_base_url(): if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +class TestOVHCloudReasoningFieldMigration: + """Tests for OVHCloud reasoning_content -> reasoning field migration.""" + + def test_streaming_new_reasoning_field(self): + """New `reasoning` field should be mapped to `reasoning_content`.""" + handler = OVHCloudChatCompletionStreamingHandler( + streaming_response=iter([]), + sync_stream=True, + ) + chunk = { + "id": "test-id", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "delta": { + "role": "assistant", + "reasoning": "Let me think...", + }, + "index": 0, + } + ], + } + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["reasoning_content"] == "Let me think..." + + def test_streaming_legacy_reasoning_content_unchanged(self): + """Legacy `reasoning_content` field should pass through untouched.""" + handler = OVHCloudChatCompletionStreamingHandler( + streaming_response=iter([]), + sync_stream=True, + ) + chunk = { + "id": "test-id", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "delta": { + "role": "assistant", + "reasoning_content": "Already correct field.", + }, + "index": 0, + } + ], + } + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["reasoning_content"] == "Already correct field." + + def test_streaming_both_fields_legacy_wins(self): + """When both fields present, existing `reasoning_content` is not overwritten.""" + handler = OVHCloudChatCompletionStreamingHandler( + streaming_response=iter([]), + sync_stream=True, + ) + chunk = { + "id": "test-id", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "delta": { + "reasoning": "new field", + "reasoning_content": "legacy field", + }, + "index": 0, + } + ], + } + result = handler.chunk_parser(chunk) + assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" \ No newline at end of file From e55e73d69b0be420a7092fcfa1159db2b7bd2d0e Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Mon, 27 Apr 2026 17:37:27 +0530 Subject: [PATCH 2/6] fix(ovhcloud): use explicit None check for seconds field in STT response Replaces falsy or with explicit is not None check so that a valid seconds=0.0 value is not silently dropped during field migration. Addresses Greptile review feedback on #26595 --- .../audio_transcription/transformation.py | 6 +++++- ...hcloud_audio_transcription_transformation.py | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/litellm/llms/ovhcloud/audio_transcription/transformation.py b/litellm/llms/ovhcloud/audio_transcription/transformation.py index e3c8308d507..f49f31d7ecd 100644 --- a/litellm/llms/ovhcloud/audio_transcription/transformation.py +++ b/litellm/llms/ovhcloud/audio_transcription/transformation.py @@ -160,7 +160,11 @@ class OVHCloudAudioTranscriptionConfig(BaseAudioTranscriptionConfig): # `duration` is replaced by `seconds` in STT responses. # Prefer `seconds`, fall back to `duration`, normalize to `duration` # so downstream consumers see a consistent key. - duration = response_json.get("seconds") or response_json.get("duration") + duration = ( + response_json["seconds"] + if "seconds" in response_json and response_json["seconds"] is not None + else response_json.get("duration") + ) if duration is not None: response_json["duration"] = duration diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py index e9abf50ba75..c8751fb2d95 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_audio_transcription_transformation.py @@ -96,4 +96,19 @@ class TestOVHCloudDurationFieldMigration: result = config.transform_audio_transcription_response(mock_response) assert result.text == "Hello world" - assert result._hidden_params["duration"] == 2.71 \ No newline at end of file + assert result._hidden_params["duration"] == 2.71 + + + + def test_seconds_zero_mapped_to_duration(self): + """seconds=0.0 must not be treated as falsy and lost.""" + from litellm.llms.ovhcloud.audio_transcription.transformation import ( + OVHCloudAudioTranscriptionConfig, + ) + from unittest.mock import MagicMock + + config = OVHCloudAudioTranscriptionConfig() + mock_response = MagicMock() + mock_response.json.return_value = {"text": "silence", "seconds": 0.0} + result = config.transform_audio_transcription_response(mock_response) + assert result._hidden_params["duration"] == 0.0 \ No newline at end of file From 982fed46321041c4bc46d02312aa731d6662bd88 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 18:09:12 +0530 Subject: [PATCH 3/6] fix(ovhcloud): handle reasoning field migration in non-streaming responses Adds transform_response to OVHCloudChatConfig to normalise the new easoning field to easoning_content in non-streaming responses, matching the existing streaming fix in chunk_parser. Addresses maintainer feedback on #26595 --- litellm/llms/ovhcloud/chat/transformation.py | 50 ++++++++++++++++-- .../test_ovhcloud_chat_transformation.py | 52 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 4100c548f2c..77d3683566c 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -5,17 +5,17 @@ Our unified API follows the OpenAI standard. More information on our website: https://endpoints.ai.cloud.ovh.net """ -from typing import Optional, Union, List +from typing import Any, Optional, Union, List import httpx -from litellm.utils import ModelResponseStream +from litellm.utils import ModelResponse, ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj from litellm.types.llms.openai import AllMessageValues - class OVHCloudChatConfig(OpenAIGPTConfig): @property def custom_llm_provider(self) -> Optional[str]: @@ -75,6 +75,50 @@ class OVHCloudChatConfig(OpenAIGPTConfig): return response + def transform_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ModelResponse, + logging_obj: LiteLLMLoggingObj, + request_data: dict, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + encoding: Any, + api_key: Optional[str] = None, + json_mode: Optional[bool] = None, + ) -> ModelResponse: + # Call parent to do standard OpenAI response parsing + model_response = super().transform_response( + model=model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + + # OVHCloud field migration (deadline: 2026-05-11): + # `reasoning_content` is replaced by `reasoning` in non-streaming responses. + # Normalise to `reasoning_content` so downstream consumers + # see a consistent key during the transition window. + for choice in model_response.choices: + message = getattr(choice, "message", None) + if message is not None: + reasoning_new = getattr(message, "reasoning", None) + reasoning_legacy = getattr(message, "reasoning_content", None) + if reasoning_new is not None and reasoning_legacy is None: + message.reasoning_content = reasoning_new + + return model_response + + class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): """ Handler for OVHCloud AI Endpoints streaming chat completion responses diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index 88ce3b4c296..b112f6d87f1 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -4,7 +4,7 @@ Unit tests for OVHCloud AI Endpoints chat integration. import os import sys - +import litellm import pytest from litellm.llms.ovhcloud.utils import OVHCloudException @@ -364,4 +364,52 @@ class TestOVHCloudReasoningFieldMigration: ], } result = handler.chunk_parser(chunk) - assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" \ No newline at end of file + assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" + + + def test_non_streaming_new_reasoning_field(self): + """Non-streaming: new `reasoning` field should be mapped to `reasoning_content`.""" + from unittest.mock import MagicMock, patch + import json + + config = OVHCloudChatConfig() + + raw_response = MagicMock() + raw_response.status_code = 200 + raw_response.headers = {"Content-Type": "application/json"} + raw_response.text = json.dumps({ + "id": "test-id", + "object": "chat.completion", + "created": 1234567890, + "model": "test-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello!", + "reasoning": "Let me think...", + }, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }) + raw_response.json.return_value = json.loads(raw_response.text) + + model_response = litellm.ModelResponse() + + result = config.transform_response( + model="ovhcloud/test-model", + raw_response=raw_response, + model_response=model_response, + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + api_key="test-key", + ) + + assert result.choices[0].message.reasoning_content == "Let me think..." \ No newline at end of file From 8f48d880da974e349deb63a47363a12c618a5301 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 18:25:11 +0530 Subject: [PATCH 4/6] style: apply black formatting to ovhcloud chat transformation --- litellm/llms/ovhcloud/chat/transformation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 77d3683566c..b5752d16309 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -16,6 +16,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj from litellm.types.llms.openai import AllMessageValues + class OVHCloudChatConfig(OpenAIGPTConfig): @property def custom_llm_provider(self) -> Optional[str]: @@ -74,7 +75,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): response.update(extra_body) return response - def transform_response( self, model: str, @@ -116,7 +116,7 @@ class OVHCloudChatConfig(OpenAIGPTConfig): if reasoning_new is not None and reasoning_legacy is None: message.reasoning_content = reasoning_new - return model_response + return model_response class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): From 90bcd232c37389397cdcb763737f150899f1f722 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 23:09:17 +0530 Subject: [PATCH 5/6] fix(ovhcloud): remove dead transform_response override The parent OpenAIGPTConfig already handles reasoning->reasoning_content for non-streaming via _extract_reasoning_content. The override was dead code giving false confidence. Streaming fix in chunk_parser is the only change needed for chat completions. Addresses Agent Shin review feedback on #26595 --- litellm/llms/ovhcloud/chat/transformation.py | 47 ++---------------- .../test_ovhcloud_chat_transformation.py | 48 +------------------ 2 files changed, 4 insertions(+), 91 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index b5752d16309..140cb855323 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -5,15 +5,15 @@ Our unified API follows the OpenAI standard. More information on our website: https://endpoints.ai.cloud.ovh.net """ -from typing import Any, Optional, Union, List +from typing import Optional, Union, List import httpx -from litellm.utils import ModelResponse, ModelResponseStream +from litellm.utils import ModelResponseStream from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig from litellm.llms.ovhcloud.utils import OVHCloudException from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator from litellm.llms.base_llm.chat.transformation import BaseLLMException -from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj + from litellm.types.llms.openai import AllMessageValues @@ -75,48 +75,7 @@ class OVHCloudChatConfig(OpenAIGPTConfig): response.update(extra_body) return response - def transform_response( - self, - model: str, - raw_response: httpx.Response, - model_response: ModelResponse, - logging_obj: LiteLLMLoggingObj, - request_data: dict, - messages: List[AllMessageValues], - optional_params: dict, - litellm_params: dict, - encoding: Any, - api_key: Optional[str] = None, - json_mode: Optional[bool] = None, - ) -> ModelResponse: - # Call parent to do standard OpenAI response parsing - model_response = super().transform_response( - model=model, - raw_response=raw_response, - model_response=model_response, - logging_obj=logging_obj, - request_data=request_data, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - encoding=encoding, - api_key=api_key, - json_mode=json_mode, - ) - # OVHCloud field migration (deadline: 2026-05-11): - # `reasoning_content` is replaced by `reasoning` in non-streaming responses. - # Normalise to `reasoning_content` so downstream consumers - # see a consistent key during the transition window. - for choice in model_response.choices: - message = getattr(choice, "message", None) - if message is not None: - reasoning_new = getattr(message, "reasoning", None) - reasoning_legacy = getattr(message, "reasoning_content", None) - if reasoning_new is not None and reasoning_legacy is None: - message.reasoning_content = reasoning_new - - return model_response class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): diff --git a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py index b112f6d87f1..40d57c76d02 100644 --- a/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py +++ b/tests/test_litellm/llms/ovhcloud/test_ovhcloud_chat_transformation.py @@ -4,7 +4,7 @@ Unit tests for OVHCloud AI Endpoints chat integration. import os import sys -import litellm + import pytest from litellm.llms.ovhcloud.utils import OVHCloudException @@ -367,49 +367,3 @@ class TestOVHCloudReasoningFieldMigration: assert result.choices[0]["delta"]["reasoning_content"] == "legacy field" - def test_non_streaming_new_reasoning_field(self): - """Non-streaming: new `reasoning` field should be mapped to `reasoning_content`.""" - from unittest.mock import MagicMock, patch - import json - - config = OVHCloudChatConfig() - - raw_response = MagicMock() - raw_response.status_code = 200 - raw_response.headers = {"Content-Type": "application/json"} - raw_response.text = json.dumps({ - "id": "test-id", - "object": "chat.completion", - "created": 1234567890, - "model": "test-model", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "Hello!", - "reasoning": "Let me think...", - }, - "finish_reason": "stop", - } - ], - "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, - }) - raw_response.json.return_value = json.loads(raw_response.text) - - model_response = litellm.ModelResponse() - - result = config.transform_response( - model="ovhcloud/test-model", - raw_response=raw_response, - model_response=model_response, - logging_obj=MagicMock(), - request_data={}, - messages=[], - optional_params={}, - litellm_params={}, - encoding=None, - api_key="test-key", - ) - - assert result.choices[0].message.reasoning_content == "Let me think..." \ No newline at end of file From d73e24c1f93664dc147ce8ef2c5a2ffcef6f9eb7 Mon Sep 17 00:00:00 2001 From: KunalG67 Date: Tue, 28 Apr 2026 23:19:13 +0530 Subject: [PATCH 6/6] fix(ovhcloud): remove dead transform_response override, parent already handles non-streaming via _extract_reasoning_content --- litellm/llms/ovhcloud/chat/transformation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/litellm/llms/ovhcloud/chat/transformation.py b/litellm/llms/ovhcloud/chat/transformation.py index 140cb855323..62f51f1e9da 100644 --- a/litellm/llms/ovhcloud/chat/transformation.py +++ b/litellm/llms/ovhcloud/chat/transformation.py @@ -76,8 +76,6 @@ class OVHCloudChatConfig(OpenAIGPTConfig): return response - - class OVHCloudChatCompletionStreamingHandler(BaseModelResponseIterator): """ Handler for OVHCloud AI Endpoints streaming chat completion responses