diff --git a/litellm/llms/azure/fine_tuning/handler.py b/litellm/llms/azure/fine_tuning/handler.py index 429b8349896..7e225a84454 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,194 @@ 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) + + 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, + 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 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 9804ff3539e..c065325254e 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 @@ -6,6 +6,55 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI from litellm._logging import verbose_logger from litellm.types.utils import LiteLLMFineTuningJob +_AZURE_STATUS_MAP = { + "pending": "queued", + "notRunning": "queued", + "running": "running", + "succeeded": "succeeded", + "failed": "failed", + "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( + data: Dict[str, Any], is_azure: bool = False +) -> Dict[str, Any]: + """ + Normalize Azure OpenAI FineTuningJob response to match OpenAI schema. + + Azure differences: + - organization_id: null → "" + - result_files: null → [] + - status: mapped via _AZURE_STATUS_MAP + """ + if not is_azure: + return data + + normalized = data.copy() + + if normalized.get("organization_id") is None: + normalized["organization_id"] = "" + + if normalized.get("result_files") is None: + normalized["result_files"] = [] + + status = normalized.get("status") + if status in _AZURE_STATUS_MAP: + normalized["status"] = _AZURE_STATUS_MAP[status] + + return normalized + + +def _litellm_fine_tuning_job_from_response( + response: Any, is_azure: bool = False +) -> LiteLLMFineTuningJob: + return LiteLLMFineTuningJob( + **_normalize_fine_tuning_job_dict(response.model_dump(), is_azure=is_azure) + ) + class OpenAIFineTuningAPI: """ @@ -60,7 +109,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 +157,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 +167,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 +213,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 +278,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 +324,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/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index aa5719bc7ac..9e669655761 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 7e238173480..7e220612ac6 100644 --- a/tests/batches_tests/test_fine_tuning_api.py +++ b/tests/batches_tests/test_fine_tuning_api.py @@ -180,6 +180,21 @@ 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, AzureOpenAIFineTuningAPI defaults it to 1. + """ + from litellm.llms.azure.fine_tuning.handler import AzureOpenAIFineTuningAPI + + handler = AzureOpenAIFineTuningAPI() + create_data = {"model": "gpt-4o-mini", "training_file": "file-test"} + + handler._ensure_training_type(create_data) + + assert "extra_body" in create_data + assert create_data["extra_body"]["trainingType"] == 1 + + @pytest.mark.asyncio() async def test_create_vertex_fine_tune_jobs_mocked(): load_vertex_ai_credentials() @@ -601,11 +616,10 @@ 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, @@ -619,8 +633,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 94221bd0efc..569743269a5 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,38 @@ 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"}, + 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 + + +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"