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"