From 265f2eb09030a6bde25c86e94860530604c18167 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 23 Mar 2026 12:11:04 +0530 Subject: [PATCH 1/6] feat(fine-tuning): fix Azure OpenAI fine-tuning job creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default trainingType=1 for Azure when omitted to avoid misleading "base model does not support fine-tuning" error - Normalize Azure FineTuningJob responses (pending→queued, null fields→defaults) to match OpenAI schema - Add pending status support to OpenAIFileObject for Azure file uploads - Add test coverage for trainingType default and response normalization Made-with: Cursor --- litellm/fine_tuning/main.py | 9 ++++ litellm/llms/openai/fine_tuning/handler.py | 43 +++++++++++++++--- tests/batches_tests/test_fine_tuning_api.py | 28 ++++++++++++ .../types/llms/test_types_llms_openai.py | 45 +++++++++++++------ 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/litellm/fine_tuning/main.py b/litellm/fine_tuning/main.py index 08373cda782..a611488b179 100644 --- a/litellm/fine_tuning/main.py +++ b/litellm/fine_tuning/main.py @@ -245,6 +245,15 @@ def create_fine_tuning_job( ) # Azure OpenAI elif custom_llm_provider == "azure": + # Azure requires trainingType (e.g. 1 = supervised). Omitting it yields a misleading + # "The specified base model does not support fine-tuning" error from the service. + if kwargs.get("trainingType") is None: + _eb = ( + kwargs.get("extra_body") or optional_params.get("extra_body") or {} + ) + if not (isinstance(_eb, dict) and _eb.get("trainingType") is not None): + kwargs["trainingType"] = 1 + api_base = optional_params.api_base or litellm.api_base or get_secret_str("AZURE_API_BASE") # type: ignore api_version = ( diff --git a/litellm/llms/openai/fine_tuning/handler.py b/litellm/llms/openai/fine_tuning/handler.py index 9804ff3539e..581ca1eea8c 100644 --- a/litellm/llms/openai/fine_tuning/handler.py +++ b/litellm/llms/openai/fine_tuning/handler.py @@ -1,4 +1,4 @@ -from typing import Any, Coroutine, Optional, Union, cast +from typing import Any, Coroutine, Dict, Optional, Union, cast import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI @@ -7,6 +7,35 @@ from litellm._logging import verbose_logger from litellm.types.utils import LiteLLMFineTuningJob +def _normalize_fine_tuning_job_dict(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Normalize Azure OpenAI FineTuningJob response to match OpenAI schema. + + Azure differences: + - organization_id: null → "" + - result_files: null → [] + - status: "pending" → "queued" + """ + normalized = data.copy() + + if normalized.get("organization_id") is None: + normalized["organization_id"] = "" + + if normalized.get("result_files") is None: + normalized["result_files"] = [] + + if normalized.get("status") == "pending": + normalized["status"] = "queued" + + return normalized + + +def _litellm_fine_tuning_job_from_response(response: Any) -> LiteLLMFineTuningJob: + return LiteLLMFineTuningJob( + **_normalize_fine_tuning_job_dict(response.model_dump()) + ) + + class OpenAIFineTuningAPI: """ OpenAI methods to support for batches @@ -60,7 +89,7 @@ class OpenAIFineTuningAPI: **create_fine_tuning_job_data ) - return LiteLLMFineTuningJob(**response.model_dump()) + return _litellm_fine_tuning_job_from_response(response) def create_fine_tuning_job( self, @@ -108,7 +137,7 @@ class OpenAIFineTuningAPI: response = cast(OpenAI, openai_client).fine_tuning.jobs.create( **create_fine_tuning_job_data ) - return LiteLLMFineTuningJob(**response.model_dump()) + return _litellm_fine_tuning_job_from_response(response) async def acancel_fine_tuning_job( self, @@ -118,7 +147,7 @@ class OpenAIFineTuningAPI: response = await openai_client.fine_tuning.jobs.cancel( fine_tuning_job_id=fine_tuning_job_id ) - return LiteLLMFineTuningJob(**response.model_dump()) + return _litellm_fine_tuning_job_from_response(response) def cancel_fine_tuning_job( self, @@ -164,7 +193,7 @@ class OpenAIFineTuningAPI: response = cast(OpenAI, openai_client).fine_tuning.jobs.cancel( fine_tuning_job_id=fine_tuning_job_id ) - return LiteLLMFineTuningJob(**response.model_dump()) + return _litellm_fine_tuning_job_from_response(response) async def alist_fine_tuning_jobs( self, @@ -229,7 +258,7 @@ class OpenAIFineTuningAPI: response = await openai_client.fine_tuning.jobs.retrieve( fine_tuning_job_id=fine_tuning_job_id ) - return LiteLLMFineTuningJob(**response.model_dump()) + return _litellm_fine_tuning_job_from_response(response) def retrieve_fine_tuning_job( self, @@ -275,4 +304,4 @@ class OpenAIFineTuningAPI: response = cast(OpenAI, openai_client).fine_tuning.jobs.retrieve( fine_tuning_job_id=fine_tuning_job_id ) - return LiteLLMFineTuningJob(**response.model_dump()) + return _litellm_fine_tuning_job_from_response(response) diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index 7e238173480..b7ff8c7fae7 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -180,6 +180,34 @@ async def test_azure_create_fine_tune_jobs_async(): pass +def test_azure_trainingtype_defaults_to_one(): + """ + Azure requires trainingType in extra_body. When omitted, LiteLLM defaults it to 1. + """ + from unittest.mock import MagicMock, patch + from litellm.fine_tuning.main import create_fine_tuning_job + + with patch( + "litellm.fine_tuning.main.azure_fine_tuning_apis_instance.create_fine_tuning_job" + ) as mock_create: + mock_create.return_value = MagicMock( + id="ftjob-test", status="queued", model="gpt-4o-mini" + ) + + create_fine_tuning_job( + model="gpt-4o-mini", + training_file="file-test", + custom_llm_provider="azure", + api_base="https://test.openai.azure.com", + api_key="test-key", + ) + + call_kwargs = mock_create.call_args[1] + create_data = call_kwargs["create_fine_tuning_job_data"] + assert "extra_body" in create_data + assert create_data["extra_body"].get("trainingType") == 1 + + @pytest.mark.asyncio() async def test_create_vertex_fine_tune_jobs_mocked(): load_vertex_ai_credentials() diff --git a/tests/test_litellm/types/llms/test_types_llms_openai.py b/tests/test_litellm/types/llms/test_types_llms_openai.py index 94221bd0efc..384d0dd73d7 100644 --- a/tests/test_litellm/types/llms/test_types_llms_openai.py +++ b/tests/test_litellm/types/llms/test_types_llms_openai.py @@ -219,11 +219,13 @@ class TestAssistantMessageImageUrlContent: # convert to list to consume it — this must not raise ValidationError. content_blocks = list(raw_content) if raw_content is not None else [] - assert len(content_blocks) == 2, ( - f"Expected 2 content blocks (text + image_url), got {len(content_blocks)}: {content_blocks}" - ) + assert ( + len(content_blocks) == 2 + ), f"Expected 2 content blocks (text + image_url), got {len(content_blocks)}: {content_blocks}" types = [b.get("type") for b in content_blocks if isinstance(b, dict)] - assert "image_url" in types, f"image_url block was silently dropped; blocks: {content_blocks}" + assert ( + "image_url" in types + ), f"image_url block was silently dropped; blocks: {content_blocks}" def test_assistant_message_image_url_preserved_in_all_message_values(self): """ @@ -255,14 +257,16 @@ class TestAssistantMessageImageUrlContent: assert assistant is not None, "Assistant message missing after serialisation" content = assistant.get("content", []) - assert isinstance(content, list), f"content should be a list, got {type(content)}" - assert len(content) == 2, ( - f"Expected 2 content blocks (text + image_url), got {len(content)}: {content}" - ) + assert isinstance( + content, list + ), f"content should be a list, got {type(content)}" + assert ( + len(content) == 2 + ), f"Expected 2 content blocks (text + image_url), got {len(content)}: {content}" types = [b.get("type") for b in content if isinstance(b, dict)] - assert "image_url" in types, ( - f"image_url block was silently dropped during AllMessageValues serialisation; blocks: {content}" - ) + assert ( + "image_url" in types + ), f"image_url block was silently dropped during AllMessageValues serialisation; blocks: {content}" class TestResponsesAPIReasoningNullFields: @@ -379,10 +383,14 @@ class TestResponsesAPIReasoningNullFields: ) dumped = response.model_dump() reasoning = [ - o for o in dumped["output"] if isinstance(o, dict) and o.get("type") == "reasoning" + o + for o in dumped["output"] + if isinstance(o, dict) and o.get("type") == "reasoning" ][0] message = [ - o for o in dumped["output"] if isinstance(o, dict) and o.get("type") == "message" + o + for o in dumped["output"] + if isinstance(o, dict) and o.get("type") == "message" ][0] assert "status" not in reasoning assert "content" not in reasoning @@ -410,3 +418,14 @@ class TestResponsesAPIReasoningNullFields: assert dumped["error"] is None assert "instructions" in dumped assert dumped["instructions"] is None + + +def test_normalize_fine_tuning_job_dict_maps_azure_pending(): + from litellm.llms.openai.fine_tuning.handler import _normalize_fine_tuning_job_dict + + out = _normalize_fine_tuning_job_dict( + {"organization_id": None, "result_files": None, "status": "pending"} + ) + assert out["organization_id"] == "" + assert out["result_files"] == [] + assert out["status"] == "queued" From 2484d202f827e838b7d2550ae98d92fc5ef9f402 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 23 Mar 2026 12:26:14 +0530 Subject: [PATCH 2/6] address greptile review feedback (greploop iteration 1) - Move trainingType injection to AzureOpenAIFineTuningAPI handler - Guard normalization with is_azure flag to only apply to Azure responses - Override acreate_fine_tuning_job in Azure handler to use is_azure=True - Update test to directly test _ensure_training_type method - Add test for OpenAI unchanged behavior Made-with: Cursor --- litellm/fine_tuning/main.py | 9 -- litellm/llms/azure/fine_tuning/handler.py | 85 ++++++++++++++++++- litellm/llms/openai/fine_tuning/handler.py | 13 ++- tests/batches_tests/test_fine_tuning_api.py | 27 ++---- .../types/llms/test_types_llms_openai.py | 11 ++- 5 files changed, 110 insertions(+), 35 deletions(-) diff --git a/litellm/fine_tuning/main.py b/litellm/fine_tuning/main.py index a611488b179..08373cda782 100644 --- a/litellm/fine_tuning/main.py +++ b/litellm/fine_tuning/main.py @@ -245,15 +245,6 @@ def create_fine_tuning_job( ) # Azure OpenAI elif custom_llm_provider == "azure": - # Azure requires trainingType (e.g. 1 = supervised). Omitting it yields a misleading - # "The specified base model does not support fine-tuning" error from the service. - if kwargs.get("trainingType") is None: - _eb = ( - kwargs.get("extra_body") or optional_params.get("extra_body") or {} - ) - if not (isinstance(_eb, dict) and _eb.get("trainingType") is not None): - kwargs["trainingType"] = 1 - api_base = optional_params.api_base or litellm.api_base or get_secret_str("AZURE_API_BASE") # type: ignore api_version = ( diff --git a/litellm/llms/azure/fine_tuning/handler.py b/litellm/llms/azure/fine_tuning/handler.py index 429b8349896..2415c7d78ed 100644 --- a/litellm/llms/azure/fine_tuning/handler.py +++ b/litellm/llms/azure/fine_tuning/handler.py @@ -1,10 +1,15 @@ -from typing import Optional, Union +from typing import Any, Coroutine, Dict, Optional, Union, cast import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI +from litellm._logging import verbose_logger from litellm.llms.azure.common_utils import BaseAzureLLM -from litellm.llms.openai.fine_tuning.handler import OpenAIFineTuningAPI +from litellm.llms.openai.fine_tuning.handler import ( + OpenAIFineTuningAPI, + _litellm_fine_tuning_job_from_response, +) +from litellm.types.utils import LiteLLMFineTuningJob class AzureOpenAIFineTuningAPI(OpenAIFineTuningAPI, BaseAzureLLM): @@ -12,6 +17,82 @@ class AzureOpenAIFineTuningAPI(OpenAIFineTuningAPI, BaseAzureLLM): AzureOpenAI methods to support fine tuning, inherits from OpenAIFineTuningAPI. """ + @staticmethod + def _ensure_training_type(create_fine_tuning_job_data: Dict[str, Any]) -> None: + """ + Azure requires trainingType in extra_body. Default to 1 (supervised) if omitted. + """ + extra_body = create_fine_tuning_job_data.get("extra_body") or {} + if not isinstance(extra_body, dict): + extra_body = {} + if extra_body.get("trainingType") is None: + extra_body["trainingType"] = 1 + create_fine_tuning_job_data["extra_body"] = extra_body + verbose_logger.debug( + "Azure fine-tuning: defaulting trainingType=1 (supervised)" + ) + + async def acreate_fine_tuning_job( + self, + create_fine_tuning_job_data: dict, + openai_client: Union[AsyncOpenAI, AsyncAzureOpenAI], + ) -> LiteLLMFineTuningJob: + response = await openai_client.fine_tuning.jobs.create( + **create_fine_tuning_job_data + ) + return _litellm_fine_tuning_job_from_response(response, is_azure=True) + + def create_fine_tuning_job( + self, + _is_async: bool, + create_fine_tuning_job_data: dict, + api_key: Optional[str], + api_base: Optional[str], + api_version: Optional[str], + timeout: Union[float, httpx.Timeout], + max_retries: Optional[int], + organization: Optional[str], + client: Optional[ + Union[OpenAI, AsyncOpenAI, AzureOpenAI, AsyncAzureOpenAI] + ] = None, + ) -> Union[LiteLLMFineTuningJob, Coroutine[Any, Any, LiteLLMFineTuningJob]]: + self._ensure_training_type(create_fine_tuning_job_data) + + openai_client: Optional[ + Union[OpenAI, AsyncOpenAI, AzureOpenAI, AsyncAzureOpenAI] + ] = self.get_openai_client( + api_key=api_key, + api_base=api_base, + timeout=timeout, + max_retries=max_retries, + organization=organization, + client=client, + _is_async=_is_async, + api_version=api_version, + ) + if openai_client is None: + raise ValueError( + "Azure OpenAI client is not initialized. Make sure api_key is passed or AZURE_API_KEY is set in the environment." + ) + + if _is_async is True: + if not isinstance(openai_client, (AsyncOpenAI, AsyncAzureOpenAI)): + raise ValueError( + "OpenAI client is not an instance of AsyncOpenAI. Make sure you passed an AsyncOpenAI client." + ) + return self.acreate_fine_tuning_job( + create_fine_tuning_job_data=create_fine_tuning_job_data, + openai_client=openai_client, + ) + + verbose_logger.debug( + "creating fine tuning job, args= %s", create_fine_tuning_job_data + ) + response = cast(OpenAI, openai_client).fine_tuning.jobs.create( + **create_fine_tuning_job_data + ) + return _litellm_fine_tuning_job_from_response(response, is_azure=True) + def get_openai_client( self, api_key: Optional[str], diff --git a/litellm/llms/openai/fine_tuning/handler.py b/litellm/llms/openai/fine_tuning/handler.py index 581ca1eea8c..2bb39aeaea4 100644 --- a/litellm/llms/openai/fine_tuning/handler.py +++ b/litellm/llms/openai/fine_tuning/handler.py @@ -7,7 +7,9 @@ from litellm._logging import verbose_logger from litellm.types.utils import LiteLLMFineTuningJob -def _normalize_fine_tuning_job_dict(data: Dict[str, Any]) -> Dict[str, Any]: +def _normalize_fine_tuning_job_dict( + data: Dict[str, Any], is_azure: bool = False +) -> Dict[str, Any]: """ Normalize Azure OpenAI FineTuningJob response to match OpenAI schema. @@ -16,6 +18,9 @@ def _normalize_fine_tuning_job_dict(data: Dict[str, Any]) -> Dict[str, Any]: - result_files: null → [] - status: "pending" → "queued" """ + if not is_azure: + return data + normalized = data.copy() if normalized.get("organization_id") is None: @@ -30,9 +35,11 @@ def _normalize_fine_tuning_job_dict(data: Dict[str, Any]) -> Dict[str, Any]: return normalized -def _litellm_fine_tuning_job_from_response(response: Any) -> LiteLLMFineTuningJob: +def _litellm_fine_tuning_job_from_response( + response: Any, is_azure: bool = False +) -> LiteLLMFineTuningJob: return LiteLLMFineTuningJob( - **_normalize_fine_tuning_job_dict(response.model_dump()) + **_normalize_fine_tuning_job_dict(response.model_dump(), is_azure=is_azure) ) diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index b7ff8c7fae7..20867234e53 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -182,30 +182,17 @@ async def test_azure_create_fine_tune_jobs_async(): def test_azure_trainingtype_defaults_to_one(): """ - Azure requires trainingType in extra_body. When omitted, LiteLLM defaults it to 1. + Azure requires trainingType in extra_body. When omitted, AzureOpenAIFineTuningAPI defaults it to 1. """ - from unittest.mock import MagicMock, patch - from litellm.fine_tuning.main import create_fine_tuning_job + from litellm.llms.azure.fine_tuning.handler import AzureOpenAIFineTuningAPI - with patch( - "litellm.fine_tuning.main.azure_fine_tuning_apis_instance.create_fine_tuning_job" - ) as mock_create: - mock_create.return_value = MagicMock( - id="ftjob-test", status="queued", model="gpt-4o-mini" - ) + handler = AzureOpenAIFineTuningAPI() + create_data = {"model": "gpt-4o-mini", "training_file": "file-test"} - create_fine_tuning_job( - model="gpt-4o-mini", - training_file="file-test", - custom_llm_provider="azure", - api_base="https://test.openai.azure.com", - api_key="test-key", - ) + handler._ensure_training_type(create_data) - call_kwargs = mock_create.call_args[1] - create_data = call_kwargs["create_fine_tuning_job_data"] - assert "extra_body" in create_data - assert create_data["extra_body"].get("trainingType") == 1 + assert "extra_body" in create_data + assert create_data["extra_body"]["trainingType"] == 1 @pytest.mark.asyncio() diff --git a/tests/test_litellm/types/llms/test_types_llms_openai.py b/tests/test_litellm/types/llms/test_types_llms_openai.py index 384d0dd73d7..323eb5a9424 100644 --- a/tests/test_litellm/types/llms/test_types_llms_openai.py +++ b/tests/test_litellm/types/llms/test_types_llms_openai.py @@ -424,8 +424,17 @@ def test_normalize_fine_tuning_job_dict_maps_azure_pending(): from litellm.llms.openai.fine_tuning.handler import _normalize_fine_tuning_job_dict out = _normalize_fine_tuning_job_dict( - {"organization_id": None, "result_files": None, "status": "pending"} + {"organization_id": None, "result_files": None, "status": "pending"}, + is_azure=True, ) assert out["organization_id"] == "" assert out["result_files"] == [] assert out["status"] == "queued" + + +def test_normalize_fine_tuning_job_dict_openai_unchanged(): + from litellm.llms.openai.fine_tuning.handler import _normalize_fine_tuning_job_dict + + data = {"organization_id": None, "result_files": None, "status": "pending"} + out = _normalize_fine_tuning_job_dict(data, is_azure=False) + assert out is data From a9c7b17bfa47346dcf036e79bfb581551f09f96f Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 23 Mar 2026 12:38:33 +0530 Subject: [PATCH 3/6] address greptile review feedback (greploop iteration 2) - Call _ensure_training_type in acreate_fine_tuning_job async override Made-with: Cursor --- litellm/llms/azure/fine_tuning/handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/llms/azure/fine_tuning/handler.py b/litellm/llms/azure/fine_tuning/handler.py index 2415c7d78ed..3d82172c585 100644 --- a/litellm/llms/azure/fine_tuning/handler.py +++ b/litellm/llms/azure/fine_tuning/handler.py @@ -37,6 +37,7 @@ class AzureOpenAIFineTuningAPI(OpenAIFineTuningAPI, BaseAzureLLM): create_fine_tuning_job_data: dict, openai_client: Union[AsyncOpenAI, AsyncAzureOpenAI], ) -> LiteLLMFineTuningJob: + self._ensure_training_type(create_fine_tuning_job_data) response = await openai_client.fine_tuning.jobs.create( **create_fine_tuning_job_data ) From d4d91684cf77df72d9d45bfd20d83ae3054c8796 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 23 Mar 2026 12:44:55 +0530 Subject: [PATCH 4/6] address greptile review feedback (greploop iteration 3) - Remove redundant _ensure_training_type call from acreate_fine_tuning_job - Use explicit _AZURE_STATUS_MAP for status normalization Made-with: Cursor --- litellm/llms/azure/fine_tuning/handler.py | 1 - litellm/llms/openai/fine_tuning/handler.py | 11 ++++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/litellm/llms/azure/fine_tuning/handler.py b/litellm/llms/azure/fine_tuning/handler.py index 3d82172c585..2415c7d78ed 100644 --- a/litellm/llms/azure/fine_tuning/handler.py +++ b/litellm/llms/azure/fine_tuning/handler.py @@ -37,7 +37,6 @@ class AzureOpenAIFineTuningAPI(OpenAIFineTuningAPI, BaseAzureLLM): create_fine_tuning_job_data: dict, openai_client: Union[AsyncOpenAI, AsyncAzureOpenAI], ) -> LiteLLMFineTuningJob: - self._ensure_training_type(create_fine_tuning_job_data) response = await openai_client.fine_tuning.jobs.create( **create_fine_tuning_job_data ) diff --git a/litellm/llms/openai/fine_tuning/handler.py b/litellm/llms/openai/fine_tuning/handler.py index 2bb39aeaea4..a9fbd88d2a8 100644 --- a/litellm/llms/openai/fine_tuning/handler.py +++ b/litellm/llms/openai/fine_tuning/handler.py @@ -6,6 +6,10 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI from litellm._logging import verbose_logger from litellm.types.utils import LiteLLMFineTuningJob +_AZURE_STATUS_MAP = { + "pending": "queued", +} + def _normalize_fine_tuning_job_dict( data: Dict[str, Any], is_azure: bool = False @@ -16,7 +20,7 @@ def _normalize_fine_tuning_job_dict( Azure differences: - organization_id: null → "" - result_files: null → [] - - status: "pending" → "queued" + - status: mapped via _AZURE_STATUS_MAP """ if not is_azure: return data @@ -29,8 +33,9 @@ def _normalize_fine_tuning_job_dict( if normalized.get("result_files") is None: normalized["result_files"] = [] - if normalized.get("status") == "pending": - normalized["status"] = "queued" + status = normalized.get("status") + if status in _AZURE_STATUS_MAP: + normalized["status"] = _AZURE_STATUS_MAP[status] return normalized From 528bac5a2734693e2a8877a878f77cb36ad559ad Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 23 Mar 2026 12:54:33 +0530 Subject: [PATCH 5/6] feat(fine-tuning): address greptile review feedback (greploop iteration 4) - Add cancel/retrieve overrides in AzureOpenAIFineTuningAPI to normalize responses - Expand _AZURE_STATUS_MAP to handle all known Azure statuses - Add "pending" to OpenAIFileObject.status allowed values - Fix async test mock to return awaitable LiteLLMFineTuningJob - Add test_openai_file_object_accepts_pending_status Made-with: Cursor --- litellm/llms/azure/fine_tuning/handler.py | 112 ++++++++++++++++++ litellm/llms/openai/fine_tuning/handler.py | 6 + litellm/types/llms/openai.py | 6 +- tests/batches_tests/test_fine_tuning_api.py | 9 +- .../types/llms/test_types_llms_openai.py | 15 +++ 5 files changed, 142 insertions(+), 6 deletions(-) diff --git a/litellm/llms/azure/fine_tuning/handler.py b/litellm/llms/azure/fine_tuning/handler.py index 2415c7d78ed..7e225a84454 100644 --- a/litellm/llms/azure/fine_tuning/handler.py +++ b/litellm/llms/azure/fine_tuning/handler.py @@ -42,6 +42,26 @@ class AzureOpenAIFineTuningAPI(OpenAIFineTuningAPI, BaseAzureLLM): ) return _litellm_fine_tuning_job_from_response(response, is_azure=True) + async def acancel_fine_tuning_job( + self, + fine_tuning_job_id: str, + openai_client: Union[AsyncOpenAI, AsyncAzureOpenAI], + ) -> LiteLLMFineTuningJob: + response = await openai_client.fine_tuning.jobs.cancel( + fine_tuning_job_id=fine_tuning_job_id + ) + return _litellm_fine_tuning_job_from_response(response, is_azure=True) + + async def aretrieve_fine_tuning_job( + self, + fine_tuning_job_id: str, + openai_client: Union[AsyncOpenAI, AsyncAzureOpenAI], + ) -> LiteLLMFineTuningJob: + response = await openai_client.fine_tuning.jobs.retrieve( + fine_tuning_job_id=fine_tuning_job_id + ) + return _litellm_fine_tuning_job_from_response(response, is_azure=True) + def create_fine_tuning_job( self, _is_async: bool, @@ -93,6 +113,98 @@ class AzureOpenAIFineTuningAPI(OpenAIFineTuningAPI, BaseAzureLLM): ) return _litellm_fine_tuning_job_from_response(response, is_azure=True) + def cancel_fine_tuning_job( + self, + _is_async: bool, + fine_tuning_job_id: str, + api_key: Optional[str], + api_base: Optional[str], + api_version: Optional[str], + timeout: Union[float, httpx.Timeout], + max_retries: Optional[int], + organization: Optional[str], + client: Optional[ + Union[OpenAI, AsyncOpenAI, AzureOpenAI, AsyncAzureOpenAI] + ] = None, + ) -> Union[LiteLLMFineTuningJob, Coroutine[Any, Any, LiteLLMFineTuningJob]]: + openai_client: Optional[ + Union[OpenAI, AsyncOpenAI, AzureOpenAI, AsyncAzureOpenAI] + ] = self.get_openai_client( + api_key=api_key, + api_base=api_base, + timeout=timeout, + max_retries=max_retries, + organization=organization, + client=client, + _is_async=_is_async, + api_version=api_version, + ) + if openai_client is None: + raise ValueError( + "Azure OpenAI client is not initialized. Make sure api_key is passed or AZURE_API_KEY is set in the environment." + ) + + if _is_async is True: + if not isinstance(openai_client, (AsyncOpenAI, AsyncAzureOpenAI)): + raise ValueError( + "OpenAI client is not an instance of AsyncOpenAI. Make sure you passed an AsyncOpenAI client." + ) + return self.acancel_fine_tuning_job( + fine_tuning_job_id=fine_tuning_job_id, + openai_client=openai_client, + ) + + response = cast(OpenAI, openai_client).fine_tuning.jobs.cancel( + fine_tuning_job_id=fine_tuning_job_id + ) + return _litellm_fine_tuning_job_from_response(response, is_azure=True) + + def retrieve_fine_tuning_job( + self, + _is_async: bool, + fine_tuning_job_id: str, + api_key: Optional[str], + api_base: Optional[str], + api_version: Optional[str], + timeout: Union[float, httpx.Timeout], + max_retries: Optional[int], + organization: Optional[str], + client: Optional[ + Union[OpenAI, AsyncOpenAI, AzureOpenAI, AsyncAzureOpenAI] + ] = None, + ) -> Union[LiteLLMFineTuningJob, Coroutine[Any, Any, LiteLLMFineTuningJob]]: + openai_client: Optional[ + Union[OpenAI, AsyncOpenAI, AzureOpenAI, AsyncAzureOpenAI] + ] = self.get_openai_client( + api_key=api_key, + api_base=api_base, + timeout=timeout, + max_retries=max_retries, + organization=organization, + client=client, + _is_async=_is_async, + api_version=api_version, + ) + if openai_client is None: + raise ValueError( + "Azure OpenAI client is not initialized. Make sure api_key is passed or AZURE_API_KEY is set in the environment." + ) + + if _is_async is True: + if not isinstance(openai_client, (AsyncOpenAI, AsyncAzureOpenAI)): + raise ValueError( + "OpenAI client is not an instance of AsyncOpenAI. Make sure you passed an AsyncOpenAI client." + ) + return self.aretrieve_fine_tuning_job( + fine_tuning_job_id=fine_tuning_job_id, + openai_client=openai_client, + ) + + response = cast(OpenAI, openai_client).fine_tuning.jobs.retrieve( + fine_tuning_job_id=fine_tuning_job_id + ) + return _litellm_fine_tuning_job_from_response(response, is_azure=True) + def get_openai_client( self, api_key: Optional[str], diff --git a/litellm/llms/openai/fine_tuning/handler.py b/litellm/llms/openai/fine_tuning/handler.py index a9fbd88d2a8..6800fe81d65 100644 --- a/litellm/llms/openai/fine_tuning/handler.py +++ b/litellm/llms/openai/fine_tuning/handler.py @@ -8,6 +8,12 @@ from litellm.types.utils import LiteLLMFineTuningJob _AZURE_STATUS_MAP = { "pending": "queued", + "notRunning": "queued", + "running": "running", + "succeeded": "succeeded", + "failed": "failed", + "canceled": "cancelled", + "canceling": "cancelled", } diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 5a80b40d61f..b9c8030c877 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -315,11 +315,11 @@ class OpenAIFileObject(BaseModel): `fine-tune`, `fine-tune-results`, `vision`, and `user_data`. """ - status: Optional[Literal["uploaded", "processed", "error"]] = None + status: Optional[Literal["uploaded", "processed", "error", "pending"]] = None """Deprecated. - The current status of the file, which can be either `uploaded`, `processed`, or - `error`. + The current status of the file, which can be either `uploaded`, `processed`, + `error`, or `pending` (Azure may return `pending` immediately after upload). """ expires_at: Optional[int] = None diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index 20867234e53..3ab15306fcb 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -616,11 +616,11 @@ async def test_mock_openai_retrieve_fine_tune_job(): @pytest.mark.asyncio async def test_mock_azure_create_fine_tune_job_with_azure_specific_params(): """Test that Azure-specific parameters are passed through extra_body""" - from openai import AsyncAzureOpenAI from openai.types.fine_tuning.fine_tuning_job import FineTuningJob from openai.types.fine_tuning.fine_tuning_job import Hyperparameters as OAIHyperparameters + from litellm.types.utils import LiteLLMFineTuningJob - mock_response = FineTuningJob( + mock_response = LiteLLMFineTuningJob( id="ft-azure-123", model="gpt-4.1-mini-2025-04-14", created_at=1677610602, @@ -634,8 +634,11 @@ async def test_mock_azure_create_fine_tune_job_with_azure_specific_params(): result_files=[], ) + async def mock_async_create(*args, **kwargs): + return mock_response + with patch("litellm.llms.azure.fine_tuning.handler.AzureOpenAIFineTuningAPI.create_fine_tuning_job") as mock_create: - mock_create.return_value = mock_response + mock_create.return_value = mock_async_create() response = await litellm.acreate_fine_tuning_job( model="gpt-4.1-mini-2025-04-14", diff --git a/tests/test_litellm/types/llms/test_types_llms_openai.py b/tests/test_litellm/types/llms/test_types_llms_openai.py index 323eb5a9424..569743269a5 100644 --- a/tests/test_litellm/types/llms/test_types_llms_openai.py +++ b/tests/test_litellm/types/llms/test_types_llms_openai.py @@ -438,3 +438,18 @@ def test_normalize_fine_tuning_job_dict_openai_unchanged(): data = {"organization_id": None, "result_files": None, "status": "pending"} out = _normalize_fine_tuning_job_dict(data, is_azure=False) assert out is data + + +def test_openai_file_object_accepts_pending_status(): + from litellm.types.llms.openai import OpenAIFileObject + + file_obj = OpenAIFileObject( + id="file-123", + bytes=1024, + created_at=1677610602, + filename="train.jsonl", + object="file", + purpose="fine-tune", + status="pending", + ) + assert file_obj.status == "pending" From e635cee712f7154c472f03c7ae4149ad167fc0d1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 23 Mar 2026 13:03:10 +0530 Subject: [PATCH 6/6] feat(fine-tuning): address greptile review feedback (greploop iteration 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused FineTuningJob import from test - Document "canceling" → "cancelled" mapping in _AZURE_STATUS_MAP Made-with: Cursor --- litellm/llms/openai/fine_tuning/handler.py | 2 ++ tests/batches_tests/test_fine_tuning_api.py | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openai/fine_tuning/handler.py b/litellm/llms/openai/fine_tuning/handler.py index 6800fe81d65..c065325254e 100644 --- a/litellm/llms/openai/fine_tuning/handler.py +++ b/litellm/llms/openai/fine_tuning/handler.py @@ -15,6 +15,8 @@ _AZURE_STATUS_MAP = { "canceled": "cancelled", "canceling": "cancelled", } +# Note: Azure's "canceling" (in-progress) is mapped to "cancelled" (terminal) +# because LiteLLMFineTuningJob schema has no intermediate cancellation state. def _normalize_fine_tuning_job_dict( diff --git a/tests/batches_tests/test_fine_tuning_api.py b/tests/batches_tests/test_fine_tuning_api.py index 3ab15306fcb..7e220612ac6 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -616,7 +616,6 @@ async def test_mock_openai_retrieve_fine_tune_job(): @pytest.mark.asyncio async def test_mock_azure_create_fine_tune_job_with_azure_specific_params(): """Test that Azure-specific parameters are passed through extra_body""" - from openai.types.fine_tuning.fine_tuning_job import FineTuningJob from openai.types.fine_tuning.fine_tuning_job import Hyperparameters as OAIHyperparameters from litellm.types.utils import LiteLLMFineTuningJob