mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
feat(fine-tuning): fix Azure OpenAI fine-tuning job creation
- 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
This commit is contained in:
parent
c89496f378
commit
9248ac8d7e
4 changed files with 105 additions and 20 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue