mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge pull request #24687 from Sameerlite/litellm_litellm_azure-finetuning-fixes
feat(fine-tuning): fix Azure OpenAI fine-tuning job creation
This commit is contained in:
commit
f3fe6d1c0a
5 changed files with 331 additions and 29 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue